module Agents
  class DataOutputAgent < Agent
    include WebRequestConcern

    cannot_be_scheduled!
    cannot_create_events!

    description  do
      <<-MD
        Data Output Agent 将接收的事件输出为RSS或JSON。 用它来输出Huginn数据的公共或私有流

        此代理将输出以下数据：

        `https://#{ENV['DOMAIN']}#{Rails.application.routes.url_helpers.web_requests_path(agent_id: ':id', user_id: user_id, secret: ':secret', format: :xml)}`

        其中：secret是您的选项中指定的允许秘密之一，扩展名可以是xml或json

        您可以设置多个机密，以便您可以单独授权外部系统访问您的Huginn数据。

        配置项:

          * `secrets` - 请求者必须提供的一组令牌，用于轻量级身份验证.
          * `expected_receive_period_in_days` - 您希望此代理从其他代理接收数据的频率
          * `template` - 一个JSON对象，表示项输出键和传入事件值之间的映射。 使用[Liquid](https://github.com/huginn/huginn/wiki/Formatting-Events-using-Liquid)格式化值。 链接，标题，描述和图标键的值将放入RSS输出的<channel>部分。 自键的值将用作此Feed本身的URL，这在通过反向代理提供时非常有用。 每个事件都会重复项密钥。 除非给定，否则每个项目的pubDate键将具有事件的创建时间。
          * `events_to_show` - 要以RSS或JSON格式输出的事件数。 （默认值：40)
          * `ttl` - RSS输出中<ttl>元素的值。 （默认值：60）
          * `ns_media` - 在输出xml中添加[yahoo media](https://en.wikipedia.org/wiki/Media_RSS)命名空间.
          * `ns_itunes` - 在输出xml中添加[itunes compatible](http://lists.apple.com/archives/syndication-dev/2005/Nov/msg00002.html) 命名空间.
          * `rss_content_type` - RSS输出的Content-Type（默认值：application / rss + xml，application / rdf + xml; q = 0.8，application / atom + xml; q = 0.6，application / xml; q = 0.4，text / xml; q = 0.4 ）
          * `response_headers` -  具有任何自定义响应标头的对象。 （例如：{“Access-Control-Allow-Origin”：“*”}）
          * `push_hubs` - 设置为每次此代理接收事件时要发布更新的PubSubHubbub端点列表。 （默认：无）热门中心包括Superfeedr和Google。 请注意，发布更新会使您的供稿网址为公众所知，因此如果您想保密，请设置反向代理以通过安全网址提供Feed，并在template.self中指定。

          如果您想输出带有属性的RSS标记，例如附件，请在`template`中使用以下内容：

            "enclosure": {
              "_attributes": {
                "url": "{{media_url}}",
                "length": "1234456789",
                "type": "audio/mpeg"
              }
            },
            "another_tag": {
              "_attributes": {
                "key": "value",
                "another_key": "another_value"
              },
              "_contents": "tag contents (can be an object for nesting)"
            }

        #  事件排序

        #{description_events_order('events')}

        DataOutputAgent将按events_order指定的顺序选择其接收事件的最后一个events_to_show条目，这些条目默认为事件创建时间。 因此，如果您有多个源代理可能在运行中创建许多事件，您可能希望增加events_to_show以具有更大的“窗口”，或者将events_order选项指定为适当的值（如date_published），以便来自各种来源的事件 在得到的饲料中适当混合。

        还有一个选项events_list_order仅控制最终输出中列出的事件的顺序，而不尝试维护接收事件的总顺序。 它具有与events_order相同的格式，默认为[[“{{_index _}}”，“number”，true]]因此所选事件以相反的顺序列出，就像大多数热门RSS源列出他们的文章一样

        # Liquid 模板

        在[Liquid](https://github.com/huginn/huginn/wiki/Formatting-Events-using-Liquid) 模板中, 有以下变量可以使用：

        * `events`:  输出的事件数组，按给定顺序排序，最多为events_to_show。 例如，如果源事件在site_title键中包含站点标题，则可以通过放置{{events.first.site_title}}在template.title中引用它。

      MD
    end

    def default_options
      {
        "secrets" => ["a-secret-key"],
        "expected_receive_period_in_days" => 2,
        "template" => {
          "title" => "XKCD comics as a feed",
          "description" => "This is a feed of recent XKCD comics, generated by Huginn",
          "item" => {
            "title" => "{{title}}",
            "description" => "Secret hovertext: {{hovertext}}",
            "link" => "{{url}}"
          }
        },
        "ns_media" => "true"
      }
    end

    def working?
      last_receive_at && last_receive_at > options['expected_receive_period_in_days'].to_i.days.ago && !recent_error_logs?
    end

    def validate_options
      if options['secrets'].is_a?(Array) && options['secrets'].length > 0
        options['secrets'].each do |secret|
          case secret
          when %r{[/.]}
            errors.add(:base, "secret may not contain a slash or dot")
          when String
          else
            errors.add(:base, "secret must be a string")
          end
        end
      else
        errors.add(:base, "Please specify one or more secrets for 'authenticating' incoming feed requests")
      end

      unless options['expected_receive_period_in_days'].present? && options['expected_receive_period_in_days'].to_i > 0
        errors.add(:base, "Please provide 'expected_receive_period_in_days' to indicate how many days can pass before this Agent is considered to be not working")
      end

      unless options['template'].present? && options['template']['item'].present? && options['template']['item'].is_a?(Hash)
        errors.add(:base, "Please provide template and template.item")
      end

      case options['push_hubs']
      when nil
      when Array
        options['push_hubs'].each do |hub|
          case hub
          when /\{/
            # Liquid templating
          when String
            begin
              URI.parse(hub)
            rescue URI::Error
              errors.add(:base, "invalid URL found in push_hubs")
              break
            end
          else
            errors.add(:base, "push_hubs must be an array of endpoint URLs")
            break
          end
        end
      else
        errors.add(:base, "push_hubs must be an array")
      end
    end

    def events_to_show
      (interpolated['events_to_show'].presence || 40).to_i
    end

    def feed_ttl
      (interpolated['ttl'].presence || 60).to_i
    end

    def feed_title
      interpolated['template']['title'].presence || "#{name} Event Feed"
    end

    def feed_link
      interpolated['template']['link'].presence || "https://#{ENV['DOMAIN']}"
    end

    def feed_url(options = {})
      interpolated['template']['self'].presence ||
        feed_link + Rails.application.routes.url_helpers.
                    web_requests_path(agent_id: id || ':id',
                                      user_id: user_id,
                                      secret: options[:secret],
                                      format: options[:format])
    end

    def feed_icon
      interpolated['template']['icon'].presence || feed_link + '/favicon.ico'
    end

    def itunes_icon
      if(boolify(interpolated['ns_itunes']))
        "<itunes:image href=#{feed_icon.encode(xml: :attr)} />"
      end  
    end

    def feed_description
      interpolated['template']['description'].presence || "A feed of Events received by the '#{name}' Huginn Agent"
    end

    def rss_content_type
      interpolated['rss_content_type'].presence || 'application/rss+xml, application/rdf+xml;q=0.8, application/atom+xml;q=0.6, application/xml;q=0.4, text/xml;q=0.4'
    end

    def xml_namespace
      namespaces = ['xmlns:atom="http://www.w3.org/2005/Atom"']

      if (boolify(interpolated['ns_media']))
        namespaces << 'xmlns:media="http://search.yahoo.com/mrss/"'
      end
      if (boolify(interpolated['ns_itunes']))
        namespaces << 'xmlns:itunes="http://www.itunes.com/dtds/podcast-1.0.dtd"'
      end
      namespaces.join(' ')
    end

    def push_hubs
      interpolated['push_hubs'].presence || []
    end

    DEFAULT_EVENTS_ORDER = {
      'events_order' => nil,
      'events_list_order' => [["{{_index_}}", "number", true]],
    }

    def events_order(key = SortableEvents::EVENTS_ORDER_KEY)
      super || DEFAULT_EVENTS_ORDER[key]
    end

    def latest_events(reload = false)
      received_events = received_events().reorder(id: :asc)

      events =
        if (event_ids = memory[:event_ids]) &&
           memory[:events_order] == events_order &&
           memory[:events_to_show] >= events_to_show
          received_events.where(id: event_ids).to_a
        else
          memory[:last_event_id] = nil
          reload = true
          []
        end

      if reload
        memory[:events_order] = events_order
        memory[:events_to_show] = events_to_show

        new_events =
          if last_event_id = memory[:last_event_id]
            received_events.where(Event.arel_table[:id].gt(last_event_id)).to_a
          else
            source_ids.flat_map { |source_id|
              # dig twice as many events as the number of
              # `events_to_show`
              received_events.where(agent_id: source_id).
                last(2 * events_to_show)
            }.sort_by(&:id)
          end

        unless new_events.empty?
          memory[:last_event_id] = new_events.last.id
          events.concat(new_events)
        end
      end

      events = sort_events(events).last(events_to_show)

      if reload
        memory[:event_ids] = events.map(&:id)
      end

      events
    end

    def receive_web_request(params, method, format)
      unless interpolated['secrets'].include?(params['secret'])
        if format =~ /json/
          return [{ error: "Not Authorized" }, 401]
        else
          return ["Not Authorized", 401]
        end
      end

      source_events = sort_events(latest_events(), 'events_list_order')

      interpolate_with('events' => source_events) do
        items = source_events.map do |event|
          interpolated = interpolate_options(options['template']['item'], event)
          interpolated['guid'] = {'_attributes' => {'isPermaLink' => 'false'},
                                  '_contents' => interpolated['guid'].presence || event.id}
          date_string = interpolated['pubDate'].to_s
          date =
            begin
              Time.zone.parse(date_string)  # may return nil
            rescue => e
              error "Error parsing a \"pubDate\" value \"#{date_string}\": #{e.message}"
              nil
            end || event.created_at
          interpolated['pubDate'] = date.rfc2822.to_s
          interpolated
        end

        now = Time.now

        if format =~ /json/
          content = {
            'title' => feed_title,
            'description' => feed_description,
            'pubDate' => now,
            'items' => simplify_item_for_json(items)
          }

          return [content, 200, "application/json", interpolated['response_headers'].presence]
        else
          hub_links = push_hubs.map { |hub|
            <<-XML
 <atom:link rel="hub" href=#{hub.encode(xml: :attr)}/>
            XML
          }.join

          items = items_to_xml(items)

          return [<<-XML, 200, rss_content_type, interpolated['response_headers'].presence]
<?xml version="1.0" encoding="UTF-8" ?>
<rss version="2.0" #{xml_namespace}>
<channel>
 <atom:link href=#{feed_url(secret: params['secret'], format: :xml).encode(xml: :attr)} rel="self" type="application/rss+xml" />
 <atom:icon>#{feed_icon.encode(xml: :text)}</atom:icon>
 #{itunes_icon}
#{hub_links}
 <title>#{feed_title.encode(xml: :text)}</title>
 <description>#{feed_description.encode(xml: :text)}</description>
 <link>#{feed_link.encode(xml: :text)}</link>
 <lastBuildDate>#{now.rfc2822.to_s.encode(xml: :text)}</lastBuildDate>
 <pubDate>#{now.rfc2822.to_s.encode(xml: :text)}</pubDate>
 <ttl>#{feed_ttl}</ttl>
#{items}
</channel>
</rss>
          XML
        end
      end
    end

    def receive(incoming_events)
      url = feed_url(secret: interpolated['secrets'].first, format: :xml)

      # Reload new events and update cache
      latest_events(true)

      push_hubs.each do |hub|
        push_to_hub(hub, url)
      end
    end

    private

    class XMLNode
      def initialize(tag_name, attributes, contents)
        @tag_name, @attributes, @contents = tag_name, attributes, contents
      end

      def to_xml(options)
        if @contents.is_a?(Hash)
          options[:builder].tag! @tag_name, @attributes do
            @contents.each { |key, value| ActiveSupport::XmlMini.to_tag(key, value, options.merge(skip_instruct: true)) }
          end
        else
          options[:builder].tag! @tag_name, @attributes, @contents
        end
      end
    end

    def simplify_item_for_xml(item)
      if item.is_a?(Hash)
        item.each.with_object({}) do |(key, value), memo|
          if value.is_a?(Hash)
            if value.key?('_attributes') || value.key?('_contents')
              memo[key] = XMLNode.new(key, value['_attributes'], simplify_item_for_xml(value['_contents']))
            else
              memo[key] = simplify_item_for_xml(value)
            end
          else
            memo[key] = value
          end
        end
      elsif item.is_a?(Array)
        item.map { |value| simplify_item_for_xml(value) }
      else
        item
      end
    end

    def simplify_item_for_json(item)
      if item.is_a?(Hash)
        item.each.with_object({}) do |(key, value), memo|
          if value.is_a?(Hash)
            if value.key?('_attributes') || value.key?('_contents')
              contents = if value['_contents'] && value['_contents'].is_a?(Hash)
                           simplify_item_for_json(value['_contents'])
                         elsif value['_contents']
                           { "contents" => value['_contents'] }
                         else
                           {}
                         end

              memo[key] = contents.merge(value['_attributes'] || {})
            else
              memo[key] = simplify_item_for_json(value)
            end
          else
            memo[key] = value
          end
        end
      elsif item.is_a?(Array)
        item.map { |value| simplify_item_for_json(value) }
      else
        item
      end
    end

    def items_to_xml(items)
      simplify_item_for_xml(items)
        .to_xml(skip_types: true, root: "items", skip_instruct: true, indent: 1)
        .gsub(%r{
          (?<indent> ^\ + ) < (?<tagname> [^> ]+ ) > \n
          (?<children>
            (?: \k<indent> \  < \k<tagname> (?:\ [^>]*)? > [^<>]*? </ \k<tagname> > \n )+
          )
          \k<indent> </ \k<tagname> > \n
        }mx) { $~[:children].gsub(/^ /, '') } # delete redundant nesting of array elements
        .gsub(%r{
          (?<indent> ^\ + ) < [^> ]+ /> \n
        }mx, '') # delete empty elements
        .gsub(%r{^</?items>\n}, '')
    end

    def push_to_hub(hub, url)
      hub_uri =
        begin
          URI.parse(hub)
        rescue URI::Error
          nil
        end

      if !hub_uri.is_a?(URI::HTTP)
        error "Invalid push endpoint: #{hub}"
        return
      end

      log "Pushing #{url} to #{hub_uri}"

      return if dry_run?

      begin
        faraday.post hub_uri, {
          'hub.mode' => 'publish',
          'hub.url' => url
        }
     rescue => e
       error "Push failed: #{e.message}"
      end
    end
  end
end
